iT邦幫忙

2024 iThome 鐵人賽

0
JavaScript

Signal API in Angular系列 第 35

Day 35 - 使用rejectErrors選項更改toSignal的錯誤處理行為

  • 分享至 

  • xImage
  •  

toSignal 函數的來源是一個 Observable,當滿足錯誤條件時,Observable 會拋出錯誤。 Observable 拋出錯誤後,每當讀取 signal 時都會拋出錯誤。對於開啟錯誤模式對話方塊 (error modal dialog) 的 global error handler 來說,這種行為可能是不受歡迎的。當讀取 signal 時,會引發錯誤,global error handler 會在非終止循環中開啟錯誤模式對話方塊。 幸運的是,toSignal 提供了 rejectErrors 選項來拒絕所有錯誤,以打破這個意外的循環。

我將在這篇文章中展示 Observable 和 toSignal 中的錯誤處理行為。他們是:

  • Observable 在拋出錯誤後停止產生值,而 AsyncPipe 顯示最後一個成功的值。
  • toSignal 錯誤處理的預設行為。 讀取 signal 時會引發錯誤。 如果應用程式有全域錯誤處理程序 (global error handler),則該處理程序會接收錯誤並進行處理。
  • 可以向 toSignal 函數傳遞 rejectErrors 選項,以將錯誤傳回給 RxJS。 RxJS 捕獲錯誤,就好像它們是 uncaught exception 一樣。 然後,signal 顯示最後一次成功的值,與 AsyncPipe 相同。
  • Observable 使用 catchError 運算子捕獲錯誤並傳回一個新的 Observable。例如,錯誤處理程序可以執行邏輯來顯示錯誤對話方塊。

建立一個可重複使用的錯誤對話方塊元件

// error-dialog.component.ts

import { ChangeDetectionStrategy, Component, computed, ElementRef, output, viewChild } from '@angular/core';

@Component({
 selector: 'app-error-dialog',
 standalone: true,
 template: `
   <dialog #dialog class="overlay">
     <ng-content>Error occurs</ng-content>
     <div>
       <button autofocus (click)="closeClicked.emit()">Close</button>
     </div>
   </dialog>`,
})
export default class ErrorDialogComponent {
 closeClicked = output();

 dialog = viewChild.required('dialog', { read: ElementRef<HTMLElement>});

 nativeDialog = computed(() => this.dialog().nativeElement);

 open() {
   this.nativeDialog().showModal();
 }

 close() {
   this.nativeDialog().close();
 }
}

ErrorDialogComponent 由一個對話框 (dialog) 組成,該對話框以程式設計方式開啟和關閉模式。此元件有一個輸出,當按一下關閉按鈕時,該輸出會向父元件發送事件。

宣告一個 Injection Token 來顯示或隱藏錯誤對話框

// error-token.constant.ts

import { InjectionToken, WritableSignal } from '@angular/core';

export const ERROR_DIALOG_TOKEN = new InjectionToken<{ show: WritableSignal<boolean> }>('ERROR_DIALOG_TOKEN');


export const appConfig = {
 providers: [
   {
     provide: ERROR_DIALOG_TOKEN,
     useValue: { show: signal(false) }
   }
 ]
}

ERROR_DIALOG_TOKEN 是一個注入令牌 (injection token),它是一個 { show: WritableSignal<boolean> } 物件。 當訊號值為 true 時,將開啟錯誤對話框。當訊號值為 false 時,錯誤對話方塊將關閉。在 appConfig 中,我將 ERROR_DIALOG_TOKEN 注入到 { show: signal(false) } 中,以便錯誤對話框保持隱藏狀態。

宣告一個全域錯誤處理程序

import { ErrorHandler, inject, Injectable } from "@angular/core";
import { ERROR_DIALOG_TOKEN } from "./error-token.constant";

@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
 modal = inject(ERROR_DIALOG_TOKEN);

 handleError(error: any) {
   console.error('in GlobalErrorHandler', error);
   this.modal.show.set(true);
 }
}

export const appConfig = {
 providers: [
   {
     provide: ErrorHandler,
     useClass: GlobalErrorHandler,
   },
 ]
}

GlobalErrorHandler 是一個集中式錯誤處理程序,用於處理應用程式的錯誤處理邏輯。在 GlobalErrorHandler 中,我注入 ERROR_DIALOG_TOKEN 以取得對 { show: WritableSignal<boolean> } 的引用。 show 切換值以開啟和關閉錯誤對話方塊。在 appConfig中,我將 ErrorHandler 注入 GlobalErrorHandler 類別 (class)。

讓我們來解釋一下 Observable 和 toSignal 的錯誤處理。

使用 AsynPipe 進行錯誤處理

// async-pipe-error.component.ts

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { BehaviorSubject, scan, map } from 'rxjs';
import { AsyncPipe } from '@angular/common';

@Component({
 selector: 'app-async-pipe-error',
 standalone: true,
 imports: [AsyncPipe],
 template: `
   <div>
     <p>total: {{ total$ | async }}</p>
   </div>
   <button (click)="something.next(1)">Add</button>
   <button (click)="something.next(-1)">Subtract</button>
 `,
})
export default class AsyncPipeErrorComponent {
 something = new BehaviorSubject(0);

 total$ = this.something.pipe(
   scan((acc, v) => acc + v, 0),
   map((v) => {
     if (v === 5) {
       throw new Error('throw an async pipe error');
     }
     return v;
   }),
 );
}

AsyncPipeErrorComponent 元件由一個 BehaviorSubject, something, 組成,初始值為0。total$ 是一個 Observable,它會累積值,當值等於 5 時拋出錯誤。 當累計值小於5時,值輸出成功。 在 HTML 範本中, Add 按鈕向 BehaviorSubject 發出1,而 Subtract 按鈕則向 BehaviorSubject 發出-1。此外,我將 AsyncPipe 匯入到 imports 以解析 $total Observable 並顯示該值。當 Observable 拋出錯誤時,AsyncPipe 將錯誤傳回給 RxJS。因此,AsyncPipe 顯示自 Observable 停止產生值以來最後一個成功的值。

toSignal 的預設錯誤處理

// default-errors.componen.ts

import { ChangeDetectionStrategy, Component, effect, inject, viewChild } from '@angular/core';
import { BehaviorSubject, map, scan } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
import ErrorDialogComponent from '../errors/error-dialog.component';
import { ERROR_DIALOG_TOKEN } from '../errors/error-token.constant';

@Component({
 selector: 'app-default-errors-example',
 standalone: true,
 imports: [ErrorDialogComponent],
 template: `
   <div>
     <p>total: {{ total() }}</p>
   </div>
   <button (click)="something.next(1)">Add</button>
   <button (click)="something.next(-1)">Subtract</button>
   <app-error-dialog (closeClicked)="modal.show.set(false)">
     <p>Error is thrown and the global error handler handles it.</p>
   </app-error-dialog>`,
})
export default class DefaultErrorsComponent {
 modal = inject(ERROR_DIALOG_TOKEN);
 errorDialog = viewChild.required(ErrorDialogComponent);
 something = new BehaviorSubject(0);

 #total$ = this.something.pipe(
   scan((acc, v) => acc + v, 0),
   map((v) => {
     if (v === 5) {
       throw new Error('Error is thrown and the global error handler handles it');
     }
     return v;
   }),
 );

 total = toSignal(this.#total$);

 constructor() {
   effect(() => {
     if (this.modal.show()) {
       console.log('in effect, open dialog');
       this.errorDialog().open();
     } else if (!this.modal.show()) {
       console.log('in effect, close dialog');
       this.errorDialog().close();
     }
   })
 }
}

DefaultErrorsComponent 元件由一個BehaviorSubject, something, 組成,初始值為0。#total$ 是一個 Observable,它會累積值並在累積值等於 5 時拋出錯誤。 當累計值小於5時,值輸出成功。 toSignal 函數從 #total$ 建立 signal 並將結果指派給 total。最後,HTML 範本顯示 total的值。

GlobalErrorHandler 類別攔截錯誤並呼叫 handlerError 方法來更新 show 訊號的值。

DefaultErrorsComponent 元件的 constructor 有一個 effect 比較 show 訊號的值。當值為 true 時,將開啟錯誤對話框,否則,將關閉錯誤對話框。但是, modal.show().set(false) 會觸發 change detection,並讀取 total。 當 Observable 拋出錯誤時,total 訊號會讀取錯誤,導致 GloblErrorHandlershow 訊號設為 true 並重新開啟錯誤對話方塊。 最終,元件陷入重新開啟錯誤對話方塊、關閉它、然後重新開啟它的循環。

這可能不是預期的行為,我可以透過傳遞 rejectErrors 屬性來修復它。

將rejectErrors屬性加入到toSignal

// reject-errors.component.ts

import { ChangeDetectionStrategy, Component, viewChild } from '@angular/core';
import { BehaviorSubject, catchError, map, scan, throwError } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
import ErrorDialogComponent from '../errors/error-dialog.component';

@Component({
 selector: 'app-reject-errors-example',
 standalone: true,
 imports: [ErrorDialogComponent],
 template: `
   <div>
     <p>total: {{ total() }}</p>
   </div>
   <button (click)="something.next(1)">Add</button>
   <button (click)="something.next(-1)">Subtract</button>
   <app-error-dialog (closeClicked)="this.errorDialog().close()">
     <p>Error Dialog only opens once.</p>
   </app-error-dialog>`,
})
export default class RejectErrorsComponent {
 errorDialog = viewChild.required(ErrorDialogComponent);
 something = new BehaviorSubject(0);

 #total$ = this.something.pipe(
   scan((acc, v) => acc + v, 0),
   map((v) => {
     if (v === 5) {
       throw new Error('throw a rejectErrors error');
     }
     return v;
   }),
   catchError((e) => {
     this.errorDialog().open();
     return throwError(() => e);
   })
 )

 total = toSignal(this.#total$, { initialValue: 0, rejectErrors: true });
}

RejectErrorsComponent 元件與 DefaultRejectComponent 元件類似,只是向 toSignal 函式傳遞了 rejectErrors 屬性。 當 #total$ Observable 拋出錯誤時,catchError 運算子捕獲錯誤,打開錯誤對話框,並重新拋出錯誤。 當 Observable 拋出錯誤時, total 訊號將其重新拋出給RxJS。 RxJS 將錯誤捕獲為 uncaught exception,並且 Observable 不會產生新值。 錯誤對話方塊打開,使用者將其關閉。 當按一下按鈕向 BehaviorSubject 發出值時,Observable 不執行任何操作, total 訊號會讀取最後一個成功值 4 並顯示它。因此,錯誤對話方塊不會再次開啟。

處理 Observable 上游的錯誤

// catch-error.component.ts

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { BehaviorSubject, catchError, of, scan, concatMap, throwError } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';

@Component({
 selector: 'app-catch-error',
 standalone: true,
 template: `
   <div>
     <p>total: {{ total() }}</p>
   </div>
   <button (click)="something.next(1)">Add</button>
   <button (click)="something.next(-1)">Subtract</button>
 `,
})
export default class CatchErrorComponent {
 something = new BehaviorSubject(0);

 total$ = this.something.pipe(
   scan((acc, v) => acc + v, 0),
   concatMap((v) => {
     if (v === 5) {
       return throwError(() => new Error('throw an error')).pipe(
         catchError((e) => {
           console.error(e);
           return of(-5);
         })
       )
     }
     return of(v);
   }),
 )

 total = toSignal(this.total$, { initialValue: 0 });
}

CatchErrorComponent 元件與其他元件的區別在於它使用 concatMap 運算子傳回內部 Observable。 當累加值不為 5 時,Observable 會傳回累加值。當累加值為5時,concatMap 呼叫 throwError 運算子拋出錯誤。然後,catchError 運算子會擷取錯誤、記錄它並傳回適當的值 -5。由於內部 Observable 處理了錯誤,因此 total$ Observable 不會意外完成。當點擊按鈕將下一個值泵送到 BehaviourSubject 時,它可以發出新值。

結論:

  • AsyncPipe 顯示 Observable 拋出錯誤後最後一次成功的值。
  • Observable 拋出錯誤後,toSignal 的預設行為是讀取錯誤,當 global error handler 不停地開啟錯誤對話方塊時,可能會導致不良結果。
  • toSignal 函數的rejectErrors 屬性模仿AsyncPipe 的行為。它將錯誤拋回 RxJS,以作為 uncaught exception。然後, signal 讀取最後一次成功的值並將其顯示在範本中。
  • 推薦的方法是處理 Observable 上游的錯誤。然後,外部 Observable 不會完成並可以恢復產生新值。

鐵人賽的第 35 天到此結束

參考:


上一篇
Day 34 - 在 toSignal 函數中使用 requireSync 選項 令 Observable 發出同步值
下一篇
Day 36 - 探索利用 signals 共享資料的不同模式
系列文
Signal API in Angular39
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言